{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<center>\n",
    "<img src=\"../../img/ods_stickers.jpg\">\n",
    "## Открытый курс по машинному обучению. Сессия № 2\n",
    "\n",
    "### <center> Автор материала: Андрей Гуревич, andrey.a.gurevich@gmail.com"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## <center> Индивидуальный проект по анализу данных </center>\n",
    "## <center> Автоматический классификатор обращений в техподдержку </center>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 1. Описание набора данных и признаков"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Примечание\n",
    "В тексте будет часто встречаться фраза \"по ряду причин\". И причины эти раскрываться не будут, потому что они либо очень пространны в изложении и не имеют никакого отношения к машинному обучению, либо попадают под положение о коммерческой тайне.\n",
    "\n",
    "Исходные данные также попадают под положение о коммерческой тайне, поэтому не могут быть распространены. Как следствие, ноутбук не является воспроизводимым без исходных данных.\n",
    "#### Цель работы\n",
    "Автоматическая классификация обращений в техподдержку вместо используемого сейчас ручного труда.\n",
    "#### Описание ситуации\n",
    "* От пользователей поступает примерно 3000 обращений в месяц в техподдержку по различным каналам (телефон, почта, самостоятельная регистрация через ServiceDesk). Все эти обращения вручную классифицируются (зачем человек обратился) и маршрутизируются (кто будет делать).\n",
    "* Недавно произошла смена программной платформы ServiceDesk с одного продукта (CA Service Desk) на другой (ServiceNow). Поэтому большая историческая база заявок у нас есть в одной системе, а новые заявки обрабатывать надо в другой\n",
    "* При смене платформы произошла полная смена классификатора. В старой было дерево предметных областей. В новой - дерево услуг, у каждой из которых есть категория, а у части - ещё подкатегория. При таком изменении подхода возникло несколько типов ситуаций:\n",
    "    * По некоторым предметным областям работы в новой системе просто не ведутся.\n",
    "    * У некоторых предметных областей из старой системы есть однозначное соответствие связки \"услуга-категория-подкатегория\" в новой.\n",
    "    * У многих произошло разбиение на 2-3 категории. Например, был \"Удалённый доступ через VPN\". Он превратился в \"Предоставление VPN\", \"Продление VPN\", \"Поддержка VPN\". Смысл разбиения: предоставление надо согласовывать со Службой информационной безопасности, продление - нет, а поддержкой занимаются совсем другие люди.\n",
    "    * Новый классификатор основан на услугах для пользователя, он ещё в процессе доработки, поэтому возникают ситуации дублирования. Например, когда пользователю надо установить клиент для VPN-подключения, это вроде бы \"Установка прочего ПО\". Но \"Поддержка VPN\" тоже подходит. Это сильно повлияет на оценку точности модели.\n",
    "* При смене платформы также инженеры получили возможность переназначить заявку, не меняя значение классификатора. В старой системе такого не было. Это сильно повысило удобство работы в системе. Но, как следствие, мы получили много заявок с неправильным выбранным значением услуги. Очевидная идея переклассифицировать существующие заявки по последнему решавшему не работает по ряду причин (не будем на них останавливаться). Так что при обработке данных значительная часть работы заключается в корректной переклассификации заявок в новую систему услуг-категорий-подкатегорий.\n",
    "\n",
    "#### Входные данные и постановка задачи\n",
    "Входные данные - письмо пользователя с описанием проблемы. На первом этапе внедрения обрабатываются только входящие письма, т.е. у нас есть заголовок письма, тело письма и его автор. \n",
    "\n",
    "На выходе надо получить:\n",
    "* Классификацию (услуга, категория, подкатегория).\n",
    "* Признак срочности (надо ли эту заявку решать раньше прочих).\n",
    "* Метрику уверенности в классификации.\n",
    "\n",
    "Особенность постановки задачи в том, что допускается ответ классификатора \"я не знаю\". Более того, этот ответ лучше, чем ошибочная классификация. Следствия такой постановки:\n",
    "* Можно игнорировать редкие классы, если модель на них будет выдавать ответ \"я не знаю\". Данные сильно несбалансированы (на этом остановимся позже), так что такая возможность сильно упрощает задачу.\n",
    "* Требуется соблюсти некий баланс между количеством заявок, по которым модель даёт уверенный ответ и количеством ошибок при этом. "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 2. Первичный анализ признаков"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "pd.options.display.max_colwidth = 500\n",
    "pd.options.display.max_columns = 500\n",
    "pd.options.display.max_rows = 999\n",
    "\n",
    "from functools import lru_cache\n",
    "\n",
    "import re\n",
    "from sklearn.externals import joblib\n",
    "\n",
    "from tqdm._tqdm_notebook import tqdm_notebook as tqdm\n",
    "tqdm.pandas()\n",
    "\n",
    "import seaborn as sns\n",
    "import matplotlib.pyplot as plt\n",
    "%matplotlib inline\n",
    "plt.rcParams['figure.figsize'] = (10, 6)\n",
    "plt.rcParams.update({'font.size': 14})\n",
    "\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.feature_extraction.text import TfidfVectorizer\n",
    "from sklearn.model_selection  import StratifiedKFold\n",
    "from sklearn.model_selection  import GridSearchCV\n",
    "from sklearn.model_selection  import cross_val_score\n",
    "\n",
    "from sklearn.linear_model import SGDClassifier\n",
    "from sklearn.ensemble import RandomForestClassifier\n",
    "from time import time\n",
    "\n",
    "from sklearn.metrics import accuracy_score\n",
    "from sklearn.metrics import f1_score\n",
    "\n",
    "\n",
    "import warnings\n",
    "warnings.filterwarnings('ignore')\n",
    "\n",
    "make_cv=True"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Данные о заявках можно получать двумя основными способами:\n",
    "* подключаться напрямую к БД и забирать данные оттуда\n",
    "* сделать предварительную выгрузку в Excel и работать уже с ней.\n",
    "\n",
    "У первого пути меньше лишних телодвижений, зато второй позволяет зафиксировать набор данных. Это важно в той ситуации, когда мы ищем оптимальный способ предварительной их трансформации перед обучением. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_snow = pd.read_excel(\"data/raw_export_snow_2017-11-09.xlsx\")\n",
    "df_snow.columns"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Признаки, которые нас интересуют на первом этапе - 'Краткое описание', 'Описание', 'Бизнес-услуга', 'Категория', 'Подкатегория'.\n",
    "При этом мы из предметной области знаем, что Описание и Подкатегория могут быть пустыми, а остальные нужные поля - нет."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_snow.describe().drop([\"top\",\"first\",\"last\"],axis=0)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*describe* это подтверждает, здесь никаких сюрпризов.\n",
    "\n",
    "Визуальный анализ длины описания будет проведён уже после обработки"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Части 4, 5. Закономерности, \"инсайты\", особенности данных. Предобработка данных."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Названия столбцов соответствуют названиям в интерфейсе ServiceNow. Но нам удобней работать с несколько иной структурой: \n",
    "* в первую очередь, надо склеить услугу, категорию и подкатегорию;\n",
    "* у заявки есть 'Краткое описание' и 'Описание' (полное). Бывают такие ситуации, когда краткое представляет из себя первые N символов полного, а бывают такие, что в кратком описании содержится очень важная информация, которой нет в полном описании. поэтому их надо склеить, избежав дублирования\n",
    "* столбцы, которые пока не участвуют в модели, удалим"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def merge_summary_and_description(row):\n",
    "    #print(row[\"summary\"])\n",
    "    \n",
    "    if row[\"description\"][:len(row[\"summary\"])+1] == row[\"summary\"]:\n",
    "        return row[\"description\"]\n",
    "    else:\n",
    "        return row[\"summary\"] + \" \" + row[\"description\"]\n",
    "\n",
    "\n",
    "df_snow.rename(columns={\"Номер\":\"ref_num\", \n",
    "                        \"Краткое описание\":\"summary\", \n",
    "                        \"Описание\":\"description\",\n",
    "                        'Группа назначения':\"AssignGroup\"\n",
    "                       }, inplace = True)\n",
    "\n",
    "\n",
    "df_snow[\"description\"].fillna(\"\",inplace=True)\n",
    "\n",
    "df_snow[\"category\"] = df_snow[\"Бизнес-услуга\"].fillna(\"\") + \"@\" + df_snow[\"Категория\"].fillna(\"\") + \"@\" + df_snow[\"Подкатегория\"].fillna(\"\")\n",
    "df_snow[\"raw_description\"] = df_snow.apply(merge_summary_and_description, axis=1)\n",
    "\n",
    "df_snow[\"raw_description\"].fillna(\" \", inplace=True)\n",
    "\n",
    "df_snow.drop(['summary', 'description', \n",
    "              'Бизнес-услуга', 'Категория', 'Подкатегория', \n",
    "              'Пользователь', 'Приоритет','Состояние',\n",
    "              'Кому назначено', 'Создано'], axis=1, inplace = True)\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_snow.columns"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Внедрение ServiceNow произошло недавно, так что для обучения у нас заявок не так уж и много:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Количество заявок из ServiceNow:\", df_snow.shape[0])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Из-за режима КТ придётся сделать таблицу с примерами, заменив в них чувствительную информацию\n",
    "\n",
    "\n",
    "\n",
    "|   |ref_num|category|raw_description|\n",
    "|:-:|:--|---|---|\n",
    "|0| T0  |Удалённый доступ к внутренним ресурсам@Электронная почта@Доступ к почте с мобильных устройств|Просьба предоставить доступ к почте через мобильное устройство|\n",
    "|1| T1  |ERP@Служебные записки@СЗ на оплату (безналичная и наличная)|>>: СЗ оплата обеспечения Письмо от: Username@companyname.com\\n\\nКоллеги, посмотрите пож-та маршрут согласования этой СЗ.\\nПодобные СЗ НЕ должны попадать на согласование к Иванову Ивану.\\n\\nС уважением,\\nСмирнова Светлана\\nНачальник управления по управлению всеми управлениями\\nДепартамент управления всеми управлениями\\n\\nT: +7 (800) 123 4567, доб.0000\\nМ: +7 (800) 123 4567\\nUsername@companyname.com\\nwww.companyname.com\\n\\nДанное электронное письмо содержит информацию, составляю...|\n",
    "|2| T2  |Электронная почта@Прочие проблемы с электронной почтой@|почта с личных e-mail Письмо от: Username@companyname.com\\n\\nКоллеги, добрый день.\\n\\nНе могли бы Вы отключить блокировку писем с частных адресов.\\nТочно знаю, что мне было отправлено письмо от адресата user@example.com., но оно до меня не дошло.\\n\\nИли пусть хотя бы приходит сообщение о том, что \"получено письмо, оставить\\nего в блоке или пропустить\".\\nЭто возможно?\\n\\nСпасибо.\\nС уважением,\\nТарасова Прасковья\\nЗаместитель начальника Отдела учёта\\nДепартамента по работе с документами\\n\\n\\nB: +7...|\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Основные проблемы того, что мы получили в **raw_description**:\n",
    "* огромные блоки \"С уважением, бла-бла-бла\"\n",
    "* после которых идёт идёт абзац про коммерческую тайну в стиле \"Если вы получили это письмо по ошибке - не надо было его читать\"\n",
    "* ServiceNow этот абзац при обработке почты разбивает переносами строки на несколько, так что вырезать регулярками 1 абзац, в котором есть выражение \"коммерческая тайна\" нельзя.\n",
    "* вариантов этого текста около 10\n",
    "* некоторые слова из него могут быть важными маркерами для категоризации, так что выкинуть все это слова через stop-list тоже нельзя\n",
    "* есть куча мусора в виде e-mail адресов, артефактов переписки типа \"Re: Re> на: ha>> fw :\" и т.д.\n",
    "* номера телефонов нам тоже не нужны\n",
    "\n",
    "Чем можно воспользоваться:\n",
    "* Номера документов имеют фиксированный формат и иногда по номеру понятно, к какому разделу ERP документ относится. Заменим их по формату на токены, DOCBUH, DOCLOGISTIC. Очевидно, что для задачи классификации информация \"в заявке указан номер бухгалтерского документа\" полезнее, чем сам номер.\n",
    "* IP и MAC адреса, также заменяем на фиксированные токены: IPADDRESS, MACADDRESS"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Pipeline а основе https://github.com/yutkin/News-Aggregator/blob/master/notebooks/news_classificaiton.ipynb\n",
    "# Реализация из sklearn не подходит, т.к. с выходом из pipeline`а надо производить и predict, и predict_proba. \n",
    "# Стандартная выполнялась бы дважды\n",
    "\n",
    "class Pipeline(object):\n",
    "    def __init__(self, *args):\n",
    "        self.transformations = args\n",
    "    def __call__(self, x):\n",
    "        res = x\n",
    "        for f in self.transformations:\n",
    "            res = f(res)\n",
    "        return res\n",
    "\n",
    "\n",
    "def remove_multispaces(x):\n",
    "    return re.sub(pattern=\" +\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def remove_respect(x):\n",
    "    if x.find(\"с уважением\")>-1:\n",
    "        return x[0:x.find(\"с уважением\")-1]\n",
    "    else:\n",
    "        return x\n",
    "\n",
    "def remove_respect2(x):\n",
    "    return re.sub(pattern='с уважением,{0,1}(\\s{1,2}.+){3}',repl=\" \",string=x,count=0)\n",
    "    \n",
    "def remove_t(x):\n",
    "    return x.replace(\"\\t\",\" \")\n",
    "def remove_n(x):\n",
    "    return x.replace(\"\\n\",\" \")\n",
    "\n",
    "def remove_lotus_adresses(x):\n",
    "    return re.sub(pattern=\"\\w+ [A-Z]( [\\w/]+@)TSAS+\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def remove_emails(x):\n",
    "    return re.sub(pattern=\"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def remove_mail_artifacts(x):\n",
    "    return re.sub(pattern=\"(письмо от:.+)|(от: .+)|(кому: .+)|(копия: .+)|(дата: .+)|(т: .+)|(м: .+)|(тел\\.{0,1}:{0,1} .+)|(phone: .+)|(mobile: .+)|(факс: .+)|(моб: .+)|(from: .+)|(sent: .+)|(to: .+)|(cc: .+)|(t: .+)|(m: .+)\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def remove_secrets(x):\n",
    "    return re.sub(pattern=\"(данное\\sэлектронное)([\\s\\S]+?)(к\\sнему\\sфайлы\\s\\(при\\sих\\sналичии\\))([\\s\\S]+?)(к\\sнему\\sфайлы)\",repl=\" \",string=x,count=0)\n",
    "\n",
    "\n",
    "def remove_lotus_names(x):\n",
    "    #Andrey A Gurevich\n",
    "    return re.sub(pattern=\"([A-Z])([a-z])+( )([A-Z])( )([A-Z])([a-z])+\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def remove_dates(x):\n",
    "    #30.01.2017 11:09 \n",
    "    return re.sub(pattern=\"\\d{2}\\.\\d{2}\\.\\d{4} \\d{2}:\\d{2}\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def replace_spp(x):\n",
    "    return re.sub(pattern=\"9-\\d+-\\d+|9-\\d+\",repl=\"SPPNUMBER\",string=x,count=0)\n",
    "\n",
    "def only_letters(x):\n",
    "    return re.sub(pattern=\"[^ A-Za-zА-Яа-я0-9]\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def remove_nananana(x):\n",
    "    return re.sub(pattern=\"(re )|(ha )|(ha )|(hа )|(на )\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def replace_nul(x):\n",
    "    return re.sub(pattern=\"nul\",repl=\" \",string=x,count=0)\n",
    "\n",
    "def no_good_days(x):\n",
    "    return re.sub(pattern=\"добрый день\",repl=\" \",string=x,count=0)\n",
    "\n",
    "\n",
    "def replace_ip(x):\n",
    "    return re.sub(pattern=\"(\\d(1,3))(\\.)(\\d(1,3))(\\.)(\\d(1,3))(\\.)(\\d(1,3))|(ip)\",repl=\"IPADDRESS\",string=x,count=0)\n",
    "\n",
    "def replace_mac(x):\n",
    "    return re.sub(pattern=\"([0-9A-Fa-f](2)[.:-]?)(5)([0-9A-Fa-f](2))\",repl=\"MACADDRESS\",string=x,count=0)\n",
    "\n",
    "def doc_buh(x):\n",
    "    return re.sub(pattern=\"(15[0-9]{8})|(21[0-9]{8})|(24[0-9]{8})|(40[0-9]{8})|(68[0-9]{8})|(70[0-9]{8})|(71[0-9]{8})\",repl=\"DOCBUH\",string=x,count=0)\n",
    "\n",
    "def doc_logistic(x):\n",
    "    return re.sub(pattern=\"(18[0-9]{8})|(28[0-9]{8})|(45[0-9]{8})|(49[0-9]{8})\",repl=\"DOCLOGISTIC\",string=x,count=0)\n",
    "\n",
    "def remove_brackets(x):\n",
    "    return re.sub(pattern=\"({)|(})\",repl=\" \",string=x,count=0)\n",
    "\n",
    "#порядок функций в pipeline важен\n",
    "\n",
    "TEXT_PIPELINE = Pipeline(str, str.lower,\n",
    "                         remove_multispaces, \n",
    "                         \n",
    "                         remove_lotus_adresses,\n",
    "                         remove_emails,\n",
    "                         remove_mail_artifacts,\n",
    "                         remove_secrets,\n",
    "                         remove_respect2,\n",
    "                         replace_ip,\n",
    "                         replace_mac,\n",
    "                         \n",
    "                         remove_t,\n",
    "                         remove_n,\n",
    "                          \n",
    "                         remove_lotus_names,\n",
    "                         remove_dates, \n",
    "                         replace_spp,\n",
    "                         #only_letters, \n",
    "                         remove_nananana, \n",
    "                         replace_nul,\n",
    "                         doc_buh,\n",
    "                         doc_logistic)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_snow[\"text\"] = df_snow[\"raw_description\"].progress_apply(TEXT_PIPELINE)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Сейчас в столбцах text у нас частично очищенное от мусора описание заявки со всем многообразием словоформ русского языка. Чтобы от этого многообразия избавиться, надо провести лемматизацию.\n",
    "В качестве лемматизатора рассматривалось два варианта: pymystem и pymorphy.\n",
    "pymorphy очень удобен в использовании, очень быстр (особенно с lru_cache) но недостаточно сильно снижает многообразие словоформ.\n",
    "pymystem довольно жёсткий (лемматизирует то, что pymorphy уже считает леммой), быстрый в linux, но имеет очень большие накладные расходы (~0.7 секунды) на запуск под windows. Т.е. обрабатывать им слова по одному - дело долгое. Зато можно все тексты слить в один файл и его обработка пройдет довольно быстро. Кроме того, в качестве параметра можно подготовить fixlist, в котором заменять всякие сокращения и популярные опечатки. Важный минус - он просто игнорирует всю латиницу."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_snow[\"text\"].to_csv(\"data/df_snow_text.csv\", encoding=\"utf-8\")\n",
    "!\"C:\\Users\\agurevich\\.local\\bin\\mystem\" -cdl --fixlist C:\\Users\\agurevich\\python\\moose\\data\\ts_fixlist.txt C:\\Users\\agurevich\\python\\moose\\data\\df_snow_text.csv C:\\Users\\agurevich\\python\\moose\\data\\df_snow_text.out\n",
    "df_snow[\"text\"] = pd.read_csv(\"data/df_snow_text.out\", header=None)[1]\n",
    "df_snow[\"text\"] = df_snow[\"text\"].apply(remove_brackets)\n",
    "df_snow[\"text\"] = df_snow[\"text\"].apply(remove_multispaces)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Посмотрим, что у нас получилось:\n",
    "\n",
    "|   |ref_num|category|raw_description|text|\n",
    "|:-:|:--|---|---|---|\n",
    "|0| T0  |Удалённый доступ к внутренним ресурсам@Электронная почта@Доступ к почте с мобильных устройств|Просьба предоставить доступ к почте через мобильное устройство|{просьба} {предоставлять} {доступ} {к} {почта} {через} {мобильный} {устройство}|\n",
    "|1| T1  |ERP@Служебные записки ERP@СЗ на оплату (безналичная и наличная)|>>: СЗ оплата обеспечения Письмо от: Username@companyname.com\\n\\nКоллеги, посмотрите пож-та маршрут согласования этой СЗ.\\nПодобные СЗ НЕ должны попадать на согласование к Иванову Ивану.\\n\\nС уважением,\\nСмирнова Светлана\\nНачальник управления по управлению всеми управлениями\\nДепартамент управления всеми управлениями\\n\\nT: +7 (800) 123 4567, доб.0000\\nМ: +7 (800) 123 4567\\nUsername@companyname.com\\nwww.companyname.com\\n\\nДанное электронное письмо содержит информацию, составляю...|>>: {сз} {оплата} {обеспечение} {коллега}, {посмотреть} {пожа?}-{тот} {маршрут} {согласование} {этот} {сз}. {подобный} {сз} {не} {должный} {попадать} {согласование} {к} {иванов} {иван}. {www??}.{companyname??}.{com??} ----- {пересылать}:  ----- {тема}: {сз} {оплата} {обеспечение} {средство} {для} {обеспечение} {участие} {в} {электронный} {процедура}. {ндс} {не}...|\n",
    "|2| T2  |Электронная почта@Прочие проблемы с электронной почтой@|почта с личных e-mail Письмо от: Username@companyname.com\\n\\nКоллеги, добрый день.\\n\\nНе могли бы Вы отключить блокировку писем с частных адресов.\\nТочно знаю, что мне было отправлено письмо от адресата\\sbrin@google.com., но оно до меня не дошло.\\n\\nИли пусть хотя бы приходит сообщение о том, что \"получено письмо, оставить\\nего в блоке или пропустить\".\\nЭто возможно?\\n\\nСпасибо.\\nС уважением,\\nТарасова Прасковья\\nЗаместитель начальника Отдела учёта\\nДепартамента по работе с документами\\n\\n\\nB: +7...|{почта} {с} {личный} {e??}-{mail??} {коллега}, {добрый} {день}. {не} {мочь} {бы} {вы} {отключать} {блокировка} {письмо} {с} {частный} {адрес}. {точно} {знать}, {что} {я} {быть} {отправлять} {письмо} {от} {адресат} , {но} {оно} {до} {я} {не} {доходить}. {или} {пусть} {хотя} {бы} {приходить} {сообщение} {о} {то}, {что} \"{получать} {письмо}, {оставлять} {он} {в} {блок} {или} {пропускать}\". {это} {возможный}? {спасибо}. {b??}: +7 (111) 111 1111111, {доба?}. 1111 {e??}: {www??}.{comp...|\n",
    "\n",
    "В результате работы mystem каждый токен, содержащий символы, заключен в фигурные скобки. Токены. имеющие проблемы с распознованием, отмечены знаками вопроса. Такие токены можно поделить на группы:\n",
    "* опечатки и сокращения в тексте: {пожа?}\n",
    "* названия продуктов и сервисов: {vpn??}\n",
    "* наши токены: {DOCBUH??}\n",
    "* части интернет-адресов: {www??}.{yandex??}.{ru??}\n",
    "\n",
    "В дальнейшем можно отдельно рассмотреть словарь с такими токенами. Важные опечатки внести в fixlist, что-то внести в stop-list vectoriser`а, что- использовать для выделения признаков для деревьев решений (про выбор модели позже). Но сейчас мы это только внесём в планы на будущее.\n",
    "\n",
    "#### Насколько нам эта обработка помогла в плане сокращения длины текстов?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_snow[\"num_tokens_raw\"]  = df_snow[\"raw_description\"].apply(lambda x: len(str(x).split())) \n",
    "df_snow[\"num_tokens_text\"] = df_snow[\"text\"].apply(lambda x: len(str(x).split())) \n",
    "fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(16, 4))\n",
    "sns.distplot( df_snow[\"num_tokens_raw\"], axlabel=\"Число токенов до обработки\", ax=axes[0])\n",
    "sns.distplot( df_snow[\"num_tokens_text\"], axlabel=\"Число токенов после обработки\", ax=axes[1]);"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Мы частично отсекли \"хвост\" и сильно увеличили долю коротких описаний. Это должно упростить задачу нашей модели.\n",
    "\n",
    "#### Посмотрим теперь на распределение целевой функции (20 наибольших значений)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "stat=df_snow[\"category\"].value_counts()\n",
    "# https://seaborn.pydata.org/examples/horizontal_barplot.html\n",
    "y_labels = []\n",
    "for i in range(0, len(stat)):\n",
    "    y_labels.append(\"Category\"+str(i))  \n",
    "\n",
    "f, ax = plt.subplots(figsize=(6, 20))\n",
    "sns.barplot( x=stat[:20].values, y=y_labels[:20]);"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Уникальных категорий:\", len(stat))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Названия категорий не указаны в качестве подписей из соображений NDA\n",
    "\n",
    "Количественное распределение говорит нам о значительном дисбалансе классов - четверть заявок относится к трём классам, половина - к 10-13 классам. \n",
    "\n",
    "При этом нам надо максимизировать количество корректных предсказаний, а не обеспечить 100% полноты по всем классам. Т.е. если корректно выделять 10 классов из 200 - то уже половина работы будет сделана. Ошибки на редких классах при этом не так важны.\n",
    "\n",
    "В такой ситуации вполне оправданно использовать метрику *accuracy* либо *f1_weighted* \n",
    "\n",
    "Для более детального анализа, при проверке качества выделения каждой из категорий, лучше использовать метрику f1. \n",
    "Кроме того, придётся ввести метрики \"доля уверенно классифицированных\" и \"доля ошибок среди уверенных классификаций\", потому что именно такие метрики соответствуют бизнес-задаче."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 6. Создание новых признаков и описание этого процесса\n",
    "В качестве признаков можно использовать счётчики количества вхождений в текст определённых ключевых слов или фраз. К примперу, слово \"Skype\" - достаточный признак для однозначной классификации. \n",
    "\n",
    "Поэтому подготовим файл с регулярными выражениями для поиска нужных ключевых слов такого вида:\n",
    "\n",
    "| Имя признака  |Выражение|Комментарий|\n",
    "|:-:|---|---|\n",
    "|kw0046|спам|   |\n",
    "|kw0102|(прогноз продажа)|Это про CRM|\n",
    "|kw0068|телефон|   |\n",
    "|kw0041|пароль|   |\n",
    "\n",
    "Прочитаем нам список ключевых слов, посчитаем количество вхождений и сохраним их в датафрейм cfm_train"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "search_patterns = pd.read_csv(\"custom_features.csv\", header=0,sep=\";\" )\n",
    "print(\"train shape:\", train_data_snow.shape)\n",
    "\n",
    "cfm_train = pd.DataFrame()\n",
    "#search_patterns\n",
    "custom_features = list()\n",
    "for i in tqdm(search_patterns.iterrows()):\n",
    "    #print(i[1].alias, i[1].pattern)\n",
    "    cfm_train[i[1].alias] = train_data_snow[\"text\"].str.count(pat=i[1].pattern)\n",
    "    custom_features.append(i[1].alias)\n",
    "print(\"cfm_train shape:\", cfm_train.shape)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "cfm_train[[\"SZ\",\"RZ\",\"Access\",\"kw0004\",\"kw0039\",\"kw0075\",\"kw0127\"]].head(10)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Полученные таким образом признаки можно совмещать с мешком слов, а можно учиться только на них. Вариант склеивания прогнозов, полученных на мешке слов с новыми признаками и повторного обучения на склейке оказался совсем плохим. \n",
    "\n",
    "RandomForest на том наборе признаков, что готовы в проекте, дает f1_weighted 0.43. Более подробно на этом останавливаться я не буду."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 7. Кросс-валидация, подбор параметров"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Выбор модели\n",
    "В качестве базового решения выбран SGDClassifier на мешке слов, который генерирует TFIDFVectoriser, потому что:\n",
    "* это простое решение из  готовых компонентов\n",
    "* эти компоненты работают быстро, как на обучение, так и на предсказание\n",
    "* на заявках из старого service desk оно сходу даёт accuracy порядка 70%.\n",
    "\n",
    "Кроме того в описаниях заявок иногда встречаются очень специфические слова-маркеры, которые позволяют с очень высокой точностью категоризировать заявку. Это хороший повод попробовать создать признаки вида \"сколько раз слово-маркер вошло в описание\" и обучить на них RandomForest (много логики типа \"в тексте упоминается мышь - на техподдержку\") и XGBoost (несколько сотен целочисленных признаков).\n",
    "\n",
    "#### Разбиение на обучающую и отложенную выборку\n",
    "\n",
    "Все преобразования сделаны до разбиения, но проблемы в этом нет: каждое преобразование производится в пределах одной заявки. В продуктиве поступающие заявки будут проходить те же самые преобразования. Так что никакого лика это не вызывает.\n",
    "\n",
    "Разбиение лучше сделать, сохранив баланс по полю category. Для этого требуется, чтобы не было таких категорий, по которым есть меньше двух заявок (чтобы хотя бы по одной попало и в train, и в test). Поэтому отрежем \"хвост\" и осуществим разбиение таким образом:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vc = pd.DataFrame(df_snow[\"category\"].value_counts())\n",
    "vc = vc[vc[\"category\"]<3]\n",
    "df_snow.drop(df_snow.index[df_snow[\"category\"].isin(vc.index)], axis=0, inplace = True)\n",
    "\n",
    "train_data_snow, test_data_snow = train_test_split(df_snow,train_size=0.7, test_size=0.3, stratify=df_snow[\"category\"], random_state=17)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "При порборе гиперпараметров разбиение на фолды будем делать стратифицированное. Но для этого надо, чтобы записей каждого класса было не меньше, чем n_splits штук. Поэтому n_splits возьмём 3, а хвост обучающей выборки опять придётся подрезать."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vc = pd.DataFrame(train_data_snow[\"category\"].value_counts())\n",
    "vc = vc[vc[\"category\"]<3]\n",
    "train_data_snow.drop(train_data_snow.index[train_data_snow[\"category\"].isin(vc.index)], axis=0, inplace = True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Подготовка мешка слов\n",
    "\n",
    "*stopwords.csv* - файл со стоп-словами, типа \"добрый\", \"день\", \"спасибо\", \"кто\" и так далее. Слова, которые совершенно точно не влияют на категоризацию, но слишком часто встречаются.\n",
    "Можно было бы обойтись без этого списка, отрегулировав параметр max_df, но у нас есть важные для принятия решения слова частотой больше 0,9."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "stop_list = pd.read_csv(\"stopwords.csv\", encoding=\"utf8\",header=None)\n",
    "vectorizer_snow = TfidfVectorizer(min_df=2, max_df=1.0, ngram_range = (1, 3), stop_words=list(stop_list))\n",
    "\n",
    "X_train_snow = vectorizer_snow.fit_transform(train_data_snow[\"text\"])\n",
    "\n",
    "target_snow = train_data_snow[\"category\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Подбор гиперпараметров и обучение "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "У SGDClassifier в версиях 0.19 и 0.21 изменился набор параметров. Попробуем выбрать способ и коэффициент регуляризации и коэффициент скорости обучения."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if make_cv:\n",
    "    kfold = StratifiedKFold(n_splits=3, shuffle=True, random_state=241)\n",
    "\n",
    "    clf = SGDClassifier(max_iter=5000) #class_weight=snow_dict\n",
    "    sgd_params = {'alpha':[1e-05, 1e-04,1e-03,0.01,0.1],\n",
    "                  'penalty':['l1','l2'],\n",
    "                  'loss': ['modified_huber'],\n",
    "                  'tol' :[1e-04,1e-03],\n",
    "                  'learning_rate': [\"constant\", \"optimal\",\"invscaling\"],\n",
    "                  'class_weight' : [\"balanced\", None],\n",
    "                  'eta0' :[0.01,0.1,0.5, 0.9]\n",
    "                 }\n",
    "    gridCV = GridSearchCV(clf, param_grid=sgd_params, scoring='accuracy', cv=kfold, n_jobs=4, verbose=1)\n",
    "\n",
    "    gridCV.fit(X_train_snow, target_snow)\n",
    "\n",
    "    print(gridCV.best_params_)\n",
    "    print(gridCV.best_score_)\n",
    "    \n",
    "#{'alpha': 0.0001, 'class_weight': None, 'eta0': 0.1, 'learning_rate': 'constant', 'loss': 'modified_huber', 'penalty': 'l1', 'tol': 0.0001}\n",
    "#0.631852279284\n",
    "\n",
    "#{'alpha': 0.0001, 'class_weight': None, 'eta0': 0.1, 'learning_rate': 'constant', 'loss': 'modified_huber', 'penalty': 'l1', 'tol': 0.0001}\n",
    "#0.632140796307"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 8. Построение кривых валидации и обучения "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Попробуем более точно подобрать параметр eta0:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if make_cv:\n",
    "    \n",
    "    cv_scores, holdout_scores = [], []\n",
    "    eta0_list = np.arange(0.01,0.5,0.0125)\n",
    "\n",
    "    for k in tqdm(eta0_list):\n",
    "        clf = SGDClassifier(max_iter=5000, \n",
    "                            alpha=0.0001, \n",
    "                            penalty=\"l1\", \n",
    "                            loss=\"modified_huber\", \n",
    "                            random_state=17, \n",
    "                            class_weight=None,\n",
    "                            tol=0.0001,\n",
    "                            learning_rate='constant',\n",
    "                            eta0=k)\n",
    "        clf.fit(X_train_snow, target_snow)\n",
    "        holdout_scores.append(accuracy_score(test_data_snow[\"category\"],\n",
    "                                             clf.predict(vectorizer_snow.transform(test_data_snow[\"text\"]))))\n",
    "\n",
    "        cv_scores.append(np.mean(cross_val_score(clf, X_train_snow, target_snow, cv=3)))\n",
    "        \n",
    "    plt.plot(eta0_list, cv_scores, label='CV')\n",
    "    plt.plot(eta0_list, holdout_scores, label='holdout')\n",
    "    plt.ylabel(\"Score\")\n",
    "    plt.xlabel(\"eta0\")\n",
    "    plt.legend();"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 9. Прогноз для тестовой или отложенной выборки"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "clf_sgd_bow_snow = SGDClassifier(alpha= 1e-04,\n",
    "                            penalty=\"l1\",\n",
    "                            loss=\"modified_huber\",\n",
    "                            random_state=17,\n",
    "                            class_weight=None,\n",
    "                            eta0 = 0.1, \n",
    "                            learning_rate=\"constant\",\n",
    "                            n_jobs=4,\n",
    "                            max_iter=5000,\n",
    "                            tol=0.0001)\n",
    "\n",
    "print(\"Обучаем SGD на мешке слов\")\n",
    "t0 = time()\n",
    "\n",
    "clf_sgd_bow_snow.fit(X_train_snow, target_snow)\n",
    "train_time = time() - t0\n",
    "print(\"Затраченное время: %0.3fs\" % train_time)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Делаем прогноз\")\n",
    "t0 = time()\n",
    "test_data_snow[\"predicted_bow\"] = clf_sgd_bow_snow.predict(vectorizer_snow.transform(test_data_snow[\"text\"]))\n",
    "train_time = time() - t0\n",
    "print(\"Затраченное время: %0.3fs\" % train_time)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Как видите, модель действительно быстрая и метод *predict* для 3к заявок отрабатывает за 0,75 секунды.\n",
    "\n",
    "Проверим стандартные метрики: accuracy и f1_weighted."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_snow = pd.DataFrame(test_data_snow[\"category\"].value_counts())\n",
    "f1_df_snow.rename(columns={\"category\":\"count\"},inplace=True)\n",
    "f1_df_snow[\"threshold\"] = pd.read_excel(\"data\\\\thresholds.xlsx\")[\"threshold\"]\n",
    "f1_df_snow[\"metric_name\"] = pd.read_excel(\"data\\\\thresholds.xlsx\")[\"metric_name\"]\n",
    "\n",
    "print(\"f1_weighted:\", str(f1_score(test_data_snow [\"category\"],test_data_snow [\"predicted_bow\"], average=\"weighted\", labels=f1_df_snow.index)))\n",
    "print(\"accuracy:\", str(accuracy_score(test_data_snow [\"category\"],test_data_snow [\"predicted_bow\"])))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "###  Часть 10. Оценка модели с описанием выбранной метрики\n",
    "Результат примерно совпадает с полученным на кросс-валидации. Но просто взять получившуюся модель и поставить её в прод нельзя из-за очень низкой точности. 40% ошибок - это недопустимо.\n",
    "Оптимистичное предположение о том, что мы сможем, обучившись на 7000 заявок, построить хорошую модель классификации на 218 категорий, оказалась несостоятельным.\n",
    "\n",
    "Можно пробовать другие модели, а можно предположить что дело именно в соотношении мощности обучающей выборки и количества категорий. Второй вариант проверить проще, ведь у нас есть база заявок из CA ServiceDesk.\n",
    "#### Подготовка данных и обучение модели на заявках из CA ServiceDesk\n",
    "Произведём все те же самые операции:\n",
    "* прочитаем выборку из Excel\n",
    "* преобразуем тексты\n",
    "* настроим гиперпараметры модели\n",
    "* обучимся и сделаем прогноз\n",
    "* посмотрим на его адекватность"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_ca = pd.read_excel(\"data/raw_export_ca.xlsx\")\n",
    "df_ca[\"summary\"] = df_ca[\"summary\"].astype(\"str\")\n",
    "df_ca[\"ref_num\"] = df_ca[\"ref_num\"].astype(\"str\")\n",
    "df_ca[\"raw_description\"] = df_ca.apply(merge_summary_and_description, axis=1)\n",
    "df_ca[\"raw_description\"].fillna(\" \", inplace=True)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Количество заявок из CA SD:\", df_ca.shape[0])\n",
    "print(\"Уникальных категорий:\", len(df_ca[\"category\"].value_counts()))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_ca[\"text\"] = df_ca[\"raw_description\"].progress_apply(TEXT_PIPELINE)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_ca[\"text\"].to_csv(\"data/df_ca_text.csv\", encoding=\"utf-8\")\n",
    "!\"C:\\Users\\agurevich\\.local\\bin\\mystem\" -cdl --fixlist C:\\Users\\agurevich\\python\\moose\\data\\ts_fixlist.txt C:\\Users\\agurevich\\python\\moose\\data\\df_ca_text.csv C:\\Users\\agurevich\\python\\moose\\data\\df_ca_text.out\n",
    "df_ca[\"text\"] = pd.read_csv(\"data/df_ca_text.out\", header=None)[1]\n",
    "df_ca[\"text\"] = df_ca[\"text\"].apply(remove_brackets)\n",
    "df_ca[\"text\"] = df_ca[\"text\"].apply(remove_multispaces)\n",
    "df_ca[\"text\"].fillna(\" \");"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vc = pd.DataFrame(df_ca[\"category\"].value_counts())\n",
    "vc = vc[vc[\"category\"]<3]\n",
    "df_ca.drop(df_ca.index[df_ca[\"category\"].isin(vc.index)], axis=0, inplace = True)\n",
    "\n",
    "train_data_ca, test_data_ca = train_test_split(df_ca,train_size=0.7, test_size=0.3, stratify=df_ca[\"category\"], random_state=17)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vc = pd.DataFrame(train_data_ca[\"category\"].value_counts())\n",
    "vc = vc[vc[\"category\"]<3]\n",
    "train_data_ca.drop(train_data_ca.index[train_data_ca[\"category\"].isin(vc.index)], axis=0, inplace = True)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "stop_list = pd.read_csv(\"stopwords.csv\", encoding=\"utf8\",header=None)\n",
    "vectorizer_ca = TfidfVectorizer(min_df=2, max_df=1.0, ngram_range = (1, 3), stop_words=list(stop_list))\n",
    "\n",
    "X_train_ca = vectorizer_ca.fit_transform(train_data_ca[\"text\"])\n",
    "\n",
    "target_ca = train_data_ca[\"category\"]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if make_cv:\n",
    "    kfold = StratifiedKFold(n_splits=3, shuffle=True, random_state=241)\n",
    "\n",
    "    clf = SGDClassifier(max_iter=5000) #class_weight=snow_dict\n",
    "    sgd_params = {'alpha':[1e-05, 1e-04],\n",
    "                  'penalty':['l1','l2'],\n",
    "                  'loss': ['modified_huber'],\n",
    "                  'tol' :[1e-04,1e-03],\n",
    "                  'learning_rate': [\"constant\", \"optimal\",\"invscaling\"],\n",
    "                  'class_weight' : [None],\n",
    "                  'eta0' :[0.05,0.1,0.2]\n",
    "                 }\n",
    "    gridCV = GridSearchCV(clf, param_grid=sgd_params, scoring='accuracy', cv=kfold, n_jobs=4, verbose=1)\n",
    "\n",
    "    gridCV.fit(X_train_ca, target_ca)\n",
    "\n",
    "    print(gridCV.best_params_)\n",
    "    print(gridCV.best_score_)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Параметры получились те же самые, но score на кросс-валидации - 0,714 против 0,635"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "clf_sgd_bow_ca = SGDClassifier(alpha= 1e-05,\n",
    "                            penalty=\"l1\",\n",
    "                            loss=\"modified_huber\",\n",
    "                            random_state=17,\n",
    "                            class_weight=None,\n",
    "                            eta0 = 0.1, \n",
    "                            learning_rate=\"constant\",\n",
    "                            tol=0.001,\n",
    "                            n_jobs=4)\n",
    "\n",
    "\n",
    "print(\"Обучаем SGD на мешке слов из старого SD\")\n",
    "t0 = time()\n",
    "\n",
    "clf_sgd_bow_ca.fit(X_train_ca, target_ca)\n",
    "train_time = time() - t0\n",
    "print(\"Затраченное время: %0.3fs\" % train_time)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Делаем прогноз\")\n",
    "t0 = time()\n",
    "test_data_ca[\"predicted_bow\"] = clf_sgd_bow_ca.predict(vectorizer_ca.transform(test_data_ca[\"text\"]))\n",
    "train_time = time() - t0\n",
    "print(\"Затраченное время: %0.3fs\" % train_time)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_ca = pd.DataFrame(test_data_ca[\"category\"].value_counts())\n",
    "f1_df_ca.rename(columns={\"category\":\"count\"},inplace=True)\n",
    "print(\"f1_weighted:\", str(f1_score(test_data_ca [\"category\"],test_data_ca [\"predicted_bow\"], average=\"weighted\", labels=f1_df_ca.index)))\n",
    "print(\"accuracy:\", str(accuracy_score(test_data_ca [\"category\"],test_data_ca [\"predicted_bow\"])))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Результат гораздо лучше, но нам надо давать прогнозы на другом наборе категорий, мы не можем использовать для этого получившуются модель. \n",
    "\n",
    "### Объединение датасетов\n",
    "\n",
    "Очевидный следующий ход - попробовать объединить подборки заявок из двух ServiceDesk. К сожалению, нельзя просто взять и склеить два датасета из старого и нового ServiceDesk. Потому что у них сильно различаются классификаторы.\n",
    "\n",
    "Например, была категория \"Сети и связь.Интернет\". Вместо неё стало две: \"Доступ к сети Интернет@Предоставление доступа в сеть Интернет@\" и \"Доступ к сети Интернет@Поддержка доступа в сеть Интернет@\".\n",
    "\n",
    "Поэтому придётся провести большую работу по сопоставлению категорий и переклассификации заявок. Сейчас это примерно 270 строк правил в виде кода на Python, которые являются объектом коммерческой тайны и не могут быть опубликованы."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import moose_modules\n",
    "df_ca, df_snow = moose_modules.merge_df(df_ca, df_snow)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Работа по сопоставлению ещё не завершена на 100%. Те заявки, по которым новая классфикация не опеределена, выбросим из рассмотрения. На этом мы потеряем примерно 13700 заявок."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Количество заявок из ServiceNow:\", df_snow.shape[0])\n",
    "print(\"Уникальных категорий в ServiceNow:\", len(df_snow[\"category\"].value_counts()))\n",
    "print(\"Количество заявок из CA SD:\", df_ca.shape[0])\n",
    "print(\"Уникальных категорий в CA SD:\", len(df_ca[\"category\"].value_counts()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Теперь надо подготовить обучающую и отложенную выборки. Важная особенность в том, что отложенная выборка должна максимально точно соответствовать ситуации в новом ServiceDesk, поэтому в неё мы отберём только заявки из df_snow. Минусом решения будет расхождение f1_weighted score на кросс-валидации и на отложенной выборке из-за разных распределений заявок по категориям. Возможно, будет лучше пользоваться accuracy."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vc = pd.DataFrame(df_snow[\"category\"].value_counts())\n",
    "vc = vc[vc[\"category\"]<3]\n",
    "df_snow.drop(df_snow.index[df_snow[\"category\"].isin(vc.index)], axis=0, inplace = True)\n",
    "\n",
    "train_data_merged, test_data_merged = train_test_split(df_snow,train_size=.3, stratify=df_snow[\"category\"], random_state=17)\n",
    "#df_both=pd.concat([df_ca, df_snow], ignore_index=True)\n",
    "#train_data, test_data = train_test_split(df_both,train_size=.3, stratify=df_both[\"category\"], random_state=17)\n",
    "\n",
    "train_data_merged = pd.concat([df_ca, train_data_merged], ignore_index=True)\n",
    "\n",
    "#train_data = train_data[train_data[\"category\"].str.contains(\"@\") ]\n",
    "#test_data = test_data[test_data[\"category\"].str.contains(\"@\") ]\n",
    "\n",
    "train_data_merged.reset_index(drop=True, inplace=True)\n",
    "test_data_merged.reset_index(drop=True, inplace=True)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Количество заявок в train_data:\", train_data_merged.shape[0])\n",
    "print(\"Уникальных категорий в train_data:\", len(train_data_merged[\"category\"].value_counts()))\n",
    "print(\"Количество заявок в test_data:\", test_data_merged.shape[0])\n",
    "print(\"Уникальных категорий в test_data:\", len(test_data_merged[\"category\"].value_counts()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Обучение на объединённом датасете"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "stop_list = pd.read_csv(\"stopwords.csv\", encoding=\"utf8\",header=None)\n",
    "vectorizer_merged = TfidfVectorizer(min_df=2, max_df=1.0, ngram_range = (1, 3), stop_words=list(stop_list))\n",
    "\n",
    "X_train_merged = vectorizer_merged.fit_transform(train_data_merged[\"text\"])\n",
    "\n",
    "target_merged = train_data_merged[\"category\"]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if make_cv:\n",
    "    kfold = StratifiedKFold(n_splits=3, shuffle=True, random_state=241)\n",
    "\n",
    "    clf = SGDClassifier(max_iter=5000) #class_weight=snow_dict\n",
    "    sgd_params = {'alpha':[1e-05, 1e-04],\n",
    "              'penalty':['l1','l2'],\n",
    "              'loss': ['modified_huber'],\n",
    "              'tol' :[1e-04,1e-03],\n",
    "              'learning_rate': [\"constant\", \"optimal\",\"invscaling\"],\n",
    "              'class_weight' : [None],\n",
    "              'eta0' :[0.05,0.1,0.2]\n",
    "             }\n",
    "    \n",
    "    \n",
    "    gridCV = GridSearchCV(clf, param_grid=sgd_params, scoring='accuracy', cv=kfold, n_jobs=4, verbose=1)\n",
    "\n",
    "    gridCV.fit(X_train_merged, target_merged)\n",
    "\n",
    "    print(gridCV.best_params_)\n",
    "    print(gridCV.best_score_)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!telegram-send \"merged $gridCV.best_score_ $gridCV.best_params_\""
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "collapsed": true
   },
   "source": [
    "Accuracy score на кросс-валидации - уже 0,793"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "clf_sgd_bow_merged = SGDClassifier(alpha= 1e-05,\n",
    "                            penalty=\"l1\",\n",
    "                            loss=\"modified_huber\",\n",
    "                            random_state=17,\n",
    "                            class_weight=None,\n",
    "                            eta0 = 0.2, \n",
    "                            learning_rate=\"constant\",\n",
    "                            n_jobs=4)\n",
    "print(\"Обучаем SGD на мешке слов из двух SD\")\n",
    "t0 = time()\n",
    "\n",
    "clf_sgd_bow_merged.fit(X_train_merged, target_merged)\n",
    "train_time = time() - t0\n",
    "print(\"Затраченное время: %0.3fs\" % train_time)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Делаем прогноз\")\n",
    "t0 = time()\n",
    "test_data_merged[\"predicted_bow\"] = clf_sgd_bow_merged.predict(vectorizer_merged.transform(test_data_merged[\"text\"]))\n",
    "train_time = time() - t0\n",
    "print(\"Затраченное время: %0.3fs\" % train_time)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_merged = pd.DataFrame(test_data_merged[\"category\"].value_counts())\n",
    "f1_df_merged.rename(columns={\"category\":\"count\"},inplace=True)\n",
    "print(\"f1_weighted:\", str(f1_score(test_data_merged [\"category\"],test_data_merged [\"predicted_bow\"], average=\"weighted\", labels=f1_df_merged.index)))\n",
    "print(\"accuracy:\", str(accuracy_score(test_data_merged [\"category\"],test_data_merged [\"predicted_bow\"])))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Вроде бы хотели как лучше, а получилось как обычно. Давайте разбираться, что с этими результатами делать.\n",
    "\n",
    "Как вообще работает многоклассовая классификация? По большому счёту, мы имеем ответ модели вида:\n",
    "\n",
    "|Название категории|Вероятность|\n",
    "|---|---|\n",
    "|Category1|0.12056546%|\n",
    "|Category2|0.051566484%|\n",
    "|Category3|0.754668788%|\n",
    "|Category4|0.000000458%|\n",
    "|...|...|\n",
    "|Category210|0.0004565%|\n",
    "\n",
    "Может быть, в тех ситуациях, когда одна из вероятностей сильно выше остальных - модель права? Нам ведь не надо делать прогнозы для 100% заявок. Нам надо делать как можно больше прогнозов с как можно меньшим количеством ошибок, мы можем иногда оставлять заявку на обработку человеку.\n",
    "\n",
    "Поступим следующим образом: \n",
    "* методом *predict_proba* получим вероятности отнесения к каждому из классов (в сумме они равны единицы)\n",
    "* максимальную вероятность назовем *max_proba*, следующую в порядке уменьшения - *second_proba*. Их разницу - *delta*, а их частное - *new_metric*\n",
    "* для удобства добавим поле *bow_right*, где будет результат сравнения факта с прогнозом\n",
    "* сделаем это для всех трёх моделей"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_temp = pd.DataFrame(np.sort(clf_sgd_bow_snow.predict_proba(vectorizer_snow.transform(test_data_snow[\"text\"])), axis=1))\n",
    "\n",
    "test_data_snow[\"max_proba\"] = df_temp[df_temp.shape[1]-1]\n",
    "test_data_snow[\"second_proba\"] = df_temp[df_temp.shape[1]-2]    \n",
    "test_data_snow[\"delta\"] = test_data_snow[\"max_proba\"] -  test_data_snow[\"second_proba\"]   \n",
    "test_data_snow[\"new_metric\"] = test_data_snow[\"max_proba\"]/test_data_snow[\"second_proba\"]\n",
    "\n",
    "#test_data[\"log_new_metric\"] = np.log(test_data[\"max_proba\"]/test_data[\"second_proba\"]) / np.log(test_data[\"new_metric\"][test_data[\"new_metric\"]!=float(\"inf\")].max())\n",
    "test_data_snow[\"bow_right\"] = (test_data_snow[\"category\"] == test_data_snow[\"predicted_bow\"])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_temp_ca = pd.DataFrame(np.sort(clf_sgd_bow_ca.predict_proba(vectorizer_ca.transform(test_data_ca[\"text\"])), axis=1))\n",
    "test_data_ca[\"max_proba\"] = df_temp_ca[df_temp_ca.shape[1]-1]\n",
    "test_data_ca[\"second_proba\"] = df_temp_ca[df_temp_ca.shape[1]-2]    \n",
    "test_data_ca[\"delta\"] = test_data_ca[\"max_proba\"] -  test_data_ca[\"second_proba\"]   \n",
    "test_data_ca[\"new_metric\"] = test_data_ca[\"max_proba\"]/test_data_ca[\"second_proba\"]\n",
    "\n",
    "#test_data[\"log_new_metric\"] = np.log(test_data[\"max_proba\"]/test_data[\"second_proba\"]) / np.log(test_data[\"new_metric\"][test_data[\"new_metric\"]!=float(\"inf\")].max())\n",
    "test_data_ca[\"bow_right\"] = (test_data_ca[\"category\"] == test_data_ca[\"predicted_bow\"])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df_temp_merged = pd.DataFrame(np.sort(clf_sgd_bow_merged.predict_proba(vectorizer_merged.transform(test_data_merged[\"text\"])), axis=1))\n",
    "test_data_merged[\"max_proba\"] = df_temp_merged[df_temp_merged.shape[1]-1]\n",
    "test_data_merged[\"second_proba\"] = df_temp_merged[df_temp_merged.shape[1]-2]    \n",
    "test_data_merged[\"delta\"] = test_data_merged[\"max_proba\"] -  test_data_merged[\"second_proba\"]   \n",
    "test_data_merged[\"new_metric\"] = test_data_merged[\"max_proba\"]/test_data_merged[\"second_proba\"]\n",
    "\n",
    "#test_data[\"log_new_metric\"] = np.log(test_data[\"max_proba\"]/test_data[\"second_proba\"]) / np.log(test_data[\"new_metric\"][test_data[\"new_metric\"]!=float(\"inf\")].max())\n",
    "test_data_merged[\"bow_right\"] = (test_data_merged[\"category\"] == test_data_merged[\"predicted_bow\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Построим графики этих новых метрик для случаев, когда модель права и случаев, когда она ошиблась. Распределения почти совпадают, так что разделить их у нас не получится."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(16, 8))\n",
    "plt.suptitle(\"Данные только из ServiceNow\")\n",
    "sns.violinplot(x=test_data_snow[\"bow_right\"], y=test_data_snow[\"max_proba\"], ax=axes[0])\n",
    "sns.violinplot(x=test_data_snow[\"bow_right\"], y=test_data_snow[\"delta\"], ax=axes[1])\n",
    "sns.violinplot(x=test_data_snow[\"bow_right\"], y=test_data_snow[test_data_snow[\"new_metric\"]<20][\"new_metric\"]);"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(16, 8))\n",
    "plt.suptitle(\"Данные только из CA ServiceDesk\")\n",
    "sns.violinplot(x=test_data_ca[\"bow_right\"], y=test_data_ca[\"max_proba\"], ax=axes[0])\n",
    "sns.violinplot(x=test_data_ca[\"bow_right\"], y=test_data_ca[\"delta\"], ax=axes[1])\n",
    "sns.violinplot(x=test_data_ca[\"bow_right\"], y=test_data_ca[test_data_ca[\"new_metric\"]<20][\"new_metric\"]);"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(16, 8))\n",
    "plt.suptitle(\"Данные из обоих сервисдесков\")\n",
    "sns.violinplot(x=test_data_merged[\"bow_right\"], y=test_data_merged[\"max_proba\"], ax=axes[0])\n",
    "sns.violinplot(x=test_data_merged[\"bow_right\"], y=test_data_merged[\"delta\"], ax=axes[1])\n",
    "sns.violinplot(x=test_data_merged[\"bow_right\"], y=test_data_merged[test_data_merged[\"new_metric\"]<20][\"new_metric\"]);"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "И вот тут оказывается, что после объединения датасетов мы смогли, наконец, сделать утверждение \"если спрогнозированная вероятность отнесения к классу высокая, то это, скорей всего, правда\". А значит, мы уже можем отделять \"уверенные прогнозы от неуверенных\".\n",
    "\n",
    "Посмотрим, что получится, если в качестве критерия уверенности принять \"max_proba>0.8\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def check_custom_metrics(df):\n",
    "    df[\"bow_is_sure_count\"]=0\n",
    "    df[\"bow_is_right_count\"]=0\n",
    "    test_data_merged[\"sure\"]=False\n",
    "    for cat_name in df.index:\n",
    "        #print(cat_name)\n",
    "        df.loc[cat_name,\"bow_is_sure_count\"] = test_data_merged[(test_data_merged[df.loc[cat_name, \"metric_name\"]]>df.loc[cat_name, \"threshold\"])\n",
    "                                                &(test_data_merged[\"predicted_bow\"]==cat_name) ].shape[0]\n",
    "        test_data_merged[\"sure\"][(test_data_merged[df.loc[cat_name, \"metric_name\"]]>df.loc[cat_name, \"threshold\"])\n",
    "                                                &(test_data_merged[\"predicted_bow\"]==cat_name) ]=True\n",
    "        df.loc[cat_name,\"bow_is_right_count\"] = test_data_merged[(test_data_merged[df.loc[cat_name, \"metric_name\"]]>df.loc[cat_name, \"threshold\"])\n",
    "                                                &(test_data_merged[\"predicted_bow\"]==cat_name) \n",
    "                                                &(test_data_merged[\"predicted_bow\"]==test_data_merged[\"category\"])].shape[0]\n",
    "    return df"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_merged[\"threshold\"] = 0.8\n",
    "f1_df_merged[\"metric_name\"] = \"max_proba\"\n",
    "\n",
    "\n",
    "check_custom_metrics(f1_df_merged)\n",
    "\n",
    "print(\"Прогнозов:\", f1_df_merged[\"bow_is_sure_count\"].sum())\n",
    "print(\"Ошибок\", f1_df_merged[\"bow_is_sure_count\"].sum()-f1_df_merged[\"bow_is_right_count\"].sum())\n",
    "print(\"Процент прогнозов\", f1_df_merged[\"bow_is_sure_count\"].sum()/f1_df_merged[\"count\"].sum())\n",
    "print(\"Процент правильных\", f1_df_merged[\"bow_is_right_count\"].sum()/f1_df_merged[\"bow_is_sure_count\"].sum())\n",
    "f1_df_merged[\"acc\"] = f1_df_merged[\"bow_is_right_count\"]/f1_df_merged[\"bow_is_sure_count\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Многовато ошибок. Попробуем поднять порог до 0,9"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_merged[\"threshold\"] = 0.9\n",
    "\n",
    "check_custom_metrics(f1_df_merged)\n",
    "\n",
    "print(\"Прогнозов:\", f1_df_merged[\"bow_is_sure_count\"].sum())\n",
    "print(\"Ошибок\", f1_df_merged[\"bow_is_sure_count\"].sum()-f1_df_merged[\"bow_is_right_count\"].sum())\n",
    "print(\"Процент прогнозов\", f1_df_merged[\"bow_is_sure_count\"].sum()/f1_df_merged[\"count\"].sum())\n",
    "print(\"Процент правильных\", f1_df_merged[\"bow_is_right_count\"].sum()/f1_df_merged[\"bow_is_sure_count\"].sum())\n",
    "f1_df_merged[\"predicted_part\"] = f1_df_merged[\"bow_is_sure_count\"]/f1_df_merged[\"count\"]\n",
    "f1_df_merged[\"accuracy_on_predicted\"] = f1_df_merged[\"bow_is_right_count\"]/f1_df_merged[\"bow_is_sure_count\"]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ошибок стало меньше, но прогнозов стало совсем мало. Попробуем посмотреть на f1 score по категориям."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_merged[\"f1\"] = f1_score(test_data_merged [\"category\"],test_data_merged [\"predicted_bow\"], \n",
    "                              average=None, labels=f1_df_merged.index)\n",
    "\n",
    "f1_df_merged.index=np.arange(0,f1_df_merged.shape[0])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "cm = sns.light_palette(\"green\", as_cmap=True)\n",
    "s = f1_df_merged[[\"count\",\"threshold\",\"metric_name\",\"f1\",\"predicted_part\",\"accuracy_on_predicted\"]].style.background_gradient(cmap=cm)\n",
    "s"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Оказывается, что по одним категориям условие было слишком строгим, а по другим - недостаточным. Поэтому после небольшого анализа мы получили следующий набор метрик и порогов:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "f1_df_merged = pd.DataFrame(test_data_merged[\"category\"].value_counts())\n",
    "f1_df_merged.rename(columns={\"category\":\"count\"},inplace=True)\n",
    "f1_df_merged[\"threshold\"] = pd.read_excel(\"data\\\\thresholds_ods.xlsx\")[\"threshold\"]\n",
    "f1_df_merged[\"metric_name\"] = pd.read_excel(\"data\\\\thresholds_ods.xlsx\")[\"metric_name\"]\n",
    "\n",
    "check_custom_metrics(f1_df_merged)\n",
    "\n",
    "print(\"Прогнозов:\", f1_df_merged[\"bow_is_sure_count\"].sum())\n",
    "print(\"Ошибок\", f1_df_merged[\"bow_is_sure_count\"].sum()-f1_df_merged[\"bow_is_right_count\"].sum())\n",
    "print(\"Процент прогнозов\", f1_df_merged[\"bow_is_sure_count\"].sum()/f1_df_merged[\"count\"].sum())\n",
    "print(\"Процент правильных\", f1_df_merged[\"bow_is_right_count\"].sum()/f1_df_merged[\"bow_is_sure_count\"].sum())\n",
    "f1_df_merged[\"predicted_part\"] = f1_df_merged[\"bow_is_sure_count\"]/f1_df_merged[\"count\"]\n",
    "f1_df_merged[\"accuracy_on_predicted\"] = f1_df_merged[\"bow_is_right_count\"]/f1_df_merged[\"bow_is_sure_count\"]\n",
    "\n",
    "f1_df_merged[\"f1\"] = f1_score(test_data_merged [\"category\"],test_data_merged [\"predicted_bow\"], \n",
    "                              average=None, labels=f1_df_merged.index)\n",
    "\n",
    "f1_df_merged.index=np.arange(0,f1_df_merged.shape[0])\n",
    "\n",
    "cm = sns.light_palette(\"green\", as_cmap=True)\n",
    "s = f1_df_merged[[\"count\",\"threshold\",\"metric_name\",\"f1\",\"predicted_part\",\"accuracy_on_predicted\"]].style.background_gradient(cmap=cm)\n",
    "s"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Часть 11. Выводы \n",
    "Процент прогнозов вырос по сравнению с тривиальным условием max_proba>0.8, но немного вырос и процент ошибок.\n",
    "\n",
    "Стоит учесть, что сейчас мы ошибкой считаем любое несовпадение прогноза с фактически указанным в отложенной выборке значением категории. И все ошибки считаем равнозначными. В реальности же при подробном рассмотрении списка ошибок выясняется, что:\n",
    "* примерно треть несовпадений прогноза и факта являются ошибками людей, которые указали неправильную категорию при регистрации и не исправили это. При этом прогноз - верный\n",
    "* треть - ошибки классификации, которые всё равно ведут к нужному назначению исполнителя. Т.е. если есть \"Категория А\" и \"Категория Б\", и по обеим исполнитель - Иванов Иван Иванович, в выборке указана \"Категория А\", а модель прогнозирует \"Категория Б\", то это ошибка, но гораздо меньшей цены, чем следующие два типа:\n",
    "* по описанию даже человеку непонятно, что это за заявка, но модель выдаёт очень уверенный прогноз\n",
    "* человеку очевидно, что за заявка, а модель даёт уверенный ошибочный прогноз\n",
    "\n",
    "Одно из направлений дальнейшего развития - учитывать веса при оценке моделей."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Эксплуатация\n",
    "Детальная проверка показала, что у разных категорий сильно отличается качество выделения. Некоторые категории модель выделяет с почти 90% точностью, а с некоторыми ошибается чаще, чем угадывает. Отсюда возникла идея вводить разные пороги уверенности для разных категорий. Кроме того, оказалось что и саму метрику, с которой сравнивать порог, лучше брать разную для разных категорий: либо max_proba, либо new_metric.\n",
    "\n",
    "Была составлена таблица со списком категорий, используемой метрики и порогом метрики. При прогнозе мы получаем название категории, по  нему определяем используемую метрику, считаем значение метрики и, таким образом, определяем, уверена ли модель в выданном прогнозе\n",
    "\n",
    "Заявки с уверенным прогнозом классифицируются в ServiceNow автоматически. С неуверенным - оставляются для ручной обработки.\n",
    "\n",
    "Используемая сейчас в продуктиве версия модели позволяет уверенно классифицировать 27% заявок."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Дальнейшие направления развития проекта\n",
    "#### Очистка данных, выделение признаков\n",
    "* Разобраться, как можно использовать неуверенно распознанные токены (что-то в признаки для деревьев, что-то в стоп-лист).\n",
    "* Часто пользователи присылают скриншот ошибки в каком-то приложении без описания. Надо распознавать хотя бы приложение - это позволить классифицировать хотя бы часть таких заявок.\n",
    "\n",
    "#### Развитие моделей\n",
    "* Подбор порогов уверенности по категорям сделать не ручным, а автоматическим с поиском оптимального значения. \n",
    "* Выделяемыми признаками покрыть все категории заявок, создать полноценную модель классификации на них.\n",
    "* Создать схему голосования моделей или усреднения их прогнозов. Возможно, это повысит качество прогнозов.\n",
    "* Учитывать веса ошибок при оценке моделей\n",
    "\n",
    "\n",
    "#### Организационные меры\n",
    "* Упростить классификатор, сократить количество элементов в нём"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.1"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
